iT邦幫忙

2022 iThome 鐵人賽

DAY 14
4
Modern Web

30個遊戲程設的錦囊妙計系列 第 14

Trick 13: 網頁遊戲的基礎建設-更新循環系統

  • 分享至 

  • xImage
  •  

從多年前開始寫遊戲就發現了,不管是用C寫GBA卡帶遊戲、用C++寫3D戰略遊戲、用Flash寫RPG、用Java寫Applet、用Unity寫VR、還是用TypeScript寫網頁遊戲,第一件工作就是先搞清楚這個開發環境的更新循環系統要怎麼建立。
網頁更新循環
在網頁遊戲的世界中,我們只要瞭解瀏覽器的運作流程就行了,不過在其他平台上其實也都是類似的概念。在網頁開啟後,瀏覽器會給每一個載入的.js檔一個執行程式碼的機會,隨後網頁就會開始它重繪網頁的無限循環。

雖然小哈的這個系列文章都是用TypeScript撰寫示範專案,不過這些專案實際在執行的時候,是把TypeScript轉譯成JavaScript,然後放進JavaScript的執行環境去運作。

那麼我們要如何在瀏覽器給我們第一次執行程式碼的機會過後,再次執行我們寫的程式呢?一般網頁可以使用瀏覽器提供的setTimeout()或setInterval()函式,規畫多少時間之後要執行哪個函式,這些規畫期程的函式會在瀏覽器『喘口氣』的時間被依序執行。

setTimeout(function, milliseconds),可以規畫一段時間後要執行哪個函式。它的第一個參數是計畫要執行的函式, 第二個參數是多少毫秒後執行。
setInterval(function, milliseconds),跟setTimeout很像,不過是每隔一段時間會重覆執行第一個參數所給的函式。

setTimeout和setInterval都有同樣的問題,這邊舉個例給大家理解這個問題的嚴重性。假設我們要以30fps的更新速率,讓角色每一幀跑10個像素,那麼我們可以如下利用setInterval來設定一個更新的函式。

/** 假設我們有一個角色的類別: Actor */
let myActor = new Actor();
/** 每一幀的更新函式 */
let moveUpdate() {
    // 將角色往右移10個像素
    myActor.x += 10;
}
/** 設定每33毫秒執行一次moveUpdate
 * 這樣一秒鐘理論上會執行1000/33 ≒ 30fps
 */
setInterval(moveUpdate, 33);

想像很美好,現實很殘酷。瀏覽器雖然會儘量趕在33毫秒後的下一次『喘口氣』的時間執行moveUpdate,但是遲到的時間它是記不住的,在執行完moveUpdate後不管剛剛遲到多久,仍要等到下一個33毫秒後的『喘口氣』才會再次執行。也就是說,如果setInterval第一次執行函式時離遊戲開始過了40毫秒,那麼再下一次執行時,就是遊戲開始後的80毫秒,再下一次就是120毫秒,第四次就是160毫秒。

理想中,四幀應該只要花33*4=132毫秒,五幀花33*5=165毫秒,但在理想中第五幀的時候,遊戲實際上才執行了四次更新。五幀就掉了一幀,這樣絕不是我們遊戲設計師能夠容忍的,是吧。

不讓它『喘口氣』

瀏覽器提供了一個特別的函式,讓我們可以在它每次『喘口氣』的時候,都執行一次我們指定的函式。

requestAnimationFrame()

這個函式可以將我們寫的函式排入瀏覽器下次『喘口氣』時執行的函式列表。注意喔!只有下次。

所以我們使用requestAnimationFrame()來建立一個無很循環的更新系統時,每次執行函式後都要再次呼叫requestAnimationFrame(),才會再次得到下次被執行的機會。

function gameUpdate() {
    ...
    /** 使用requestAnimationFrame()來取得下次執行gameUpdate的機會
     */
    requestAnimationFrame(gameUpdate);
}
/** 在第一次獲得瀏覽器給的執行機會時
 * 使用requestAnimationFrame()來取得下次執行gameUpdate的機會
 */
requestAnimationFrame(gameUpdate);

另外要記得一點,就是requestAnimationFrame()會回傳一個整數,作為這次排程的收據。如果之後想要取消這次的排程,可以呼叫cancelAnimationFrame(收據)來取消。

// 使用requestAnimationFrame()來取得下次執行gameUpdate的機會
let receipt = requestAnimationFrame(gameUpdate);
// 取消剛剛的排程
cancelAnimationFrame(receipt);

建立自己的更新循環系統

知道了怎麼在瀏覽器中安插自己的更新函式,我們就可以來設計遊戲用的更新循環系統了。

這邊列一下我們的更新循環系統應該要有哪些功能。

  1. 能夠設定fps,並自動調整循環時間的間隔來符合長期穩定的fps。
  2. 能夠加入需要每幀更新的函式。
  3. 能夠移除每幀更新的函式。
  4. 能夠暫停。
  5. 能夠取得距離遊戲開始後經過的時間和幀數。

其實應該還可以有更多功能,比如可以加入類似setInterval()的功能等等,但是這邊寫太多就顯得太不信任同學們的能力,所以其他的就交給大家自行去發揮吧。

/** 定義一下可以放入這個系統排程的函式型別
 * 回傳值隨便什麼都行(any),反正我們也不會管。
 * 同學們在設計自己的系統時,可以塞給這些函式一些重要的參數,
 * 比如目前的系統時間什麼的。
 * 在這邊我設計得比較單純,沒給任何參數。
 */
type UpdaterFunction = () => any;
/** 將這個更新循環系統命名為Updater */
class Updater {
    // 預設每秒執行30次(frame per second)
    fps = 30;
    // 需要執行的函式列表
    funcsToRun: UpdaterFunction[] = [];
    // 啟動時的系統時間
    startSystemTime = 0;
    // 目前遊戲累積經過的時間
    currentTime = 0;
    // 目前幀數
    currentFrame = 0;
    // 目前是否正在運作中, 預設是 false
    running = false;
    // 記錄requestAnimationFrame給的收據
    animFrameID = 0;
    
    // 開始運作,參數是fps
    start(fps: number): void {
        this.fps = fps;
        // 重置時間和屬性,
        // performance.now()的時間和requestAnimationFrame()是對齊的
        this.startSystemTime = performance.now();
        this.currentTime = 0;
        this.currentFrame = 0;
        // 開始嘍
        this.running = true;
        // 要求瀏覽器下次喘氣時呼叫我的this.update()
        this.animFrameID = requestAnimationFrame(this.update);
    }
    
    // 停止運作
    stop(): void {
        this.running = false;
        cancelAnimationFrame(this.animFrameID);
    }
    
    // 加入排程的函式
    addFunction(func: UpdaterFunction): void {
        this.funcsToRun.push(func);
    }
    // 移除排程的函式
    removeFunction(func: UpdaterFunction): void {
        // 先找到這個函式在陣列中的位置
        let index = this.funcsToRun.indexOf(func);
        // 如果位置不是-1,代表找到了
        if(index != -1) {
            /** 將這個位置上的元素改為null。
             * 不直接將這個元素從陣列中移除是有原因的,下面再談。
             */
            this.funcsToRun[index] = null;
        }
    }
    
    /** 將目前幀數前進一格,並執行所有排程的函式 */
     advanceFrame() {
         this.currentFrame++;
         // 執行所有排程的函式
         let length = this.funcsToRun.length;
         for(let i = 0; i < length; i++) {
             let func = this.funcsToRun[i];
             // func有可能在removeFunction裏被設為null
             // 所以要先確定func不是null才去執行
             if(func) {
                 func();
             } else {
                 // 如果func是null,我們就把這個陣列的位置刪掉
                 // 呼叫Array.splice,從i這個位置開始註後刪1個元素
                 this.funcsToRun.splice(i, 1);
                 // 等一下i馬上會i++
                 // 所以要先讓i回到上個位置,等一下才會回到正確的下個位置
                 i--;
             }
         }
     }
    
    /** 重點來了,本系統的更新函式
     * 這裏必須使用箭頭函式,原因留到下面再講
     * requestAnimationFrame在呼叫我們的this.update時
     * 會附送一個目前時間的參數,我們將這參數命名為now
     */
     update = (now: number) => {
         // 首先,先安排this.update下一幀還要被呼叫
         this.animFrameID = requestAnimationFrame(this.update);
         // 計算從開始到現在過了多久
         this.currentTime = now - this.startSystemTime;
         // 計算現在應該要執行第幾幀了
         let nextFrame = this.currentTime * 0.001 * this.fps;
         // 如果目前幀數小於應該要執行的幀數,那就將目前幀數前進一格
         if(this.currentFrame < nextFrame) {
             this.advanceFrame();
             // 如果前進一幀之後,還是落後,那麼再前進一幀
             if(this.currentFrame < nextFrame) {
                 // 這是電腦因為某些緣故變慢時的補救措施
                 // 這裏寫的是最簡單的補救方法
                 // 同學們可以依需求想出別的方法
                 this.advanceFrame();
             }
         }
     }
}

這樣,我們的更新循環系統就完成了,實際使用的方法如下。

/** 先定義一個遊戲的類別 */
class Game {
    // 這邊也要用箭頭函式,原因下面再說。
    updateActors = () => {
        // 空的更新函式,純示範
    }
}
// 建立遊戲
let game = new Game();
// 新增一個更新循環系統
let updater = new Updater();
// 開始運作
updater.start(30);
// 排入遊戲的更新函式
updater.addFunction(game.updateActors);
// 這樣game.updateActors就會每秒被執行30次了

CG示範專案


上面的示範程式留下了兩個謎團:

  1. 為什麼在removeFunction時,不直接把該函式從陣列中移除,而是先設定為null,等執行時才正式移除?
  2. 為什麼非要用箭頭函式不可?

為什麼在移除時不直接移除

如果有製作遊戲經驗的朋友,肯定已經知道原因了,因為這是遊戲製作中很常見的問題。

遊戲中的物件之間牽扯緊密,有時更新一個物件,卻影響到其他十多個物件的情況也不奇怪。比如說有一顆炸彈爆炸了,在更新這個爆炸的時候,同時讓三個角色死亡,並移除附近的二十朵花,外加新增五團煙霧和一個在地板的焦黑痕跡。

請大家想想喔,在炸彈爆炸的時候,除了炸彈本身移除時需要去更新循環系統移除自己的更新函式,同時也可能為了角色死亡以及花被炸碎,而去移除他們各自的更新函式。

也就是說,在advanceFrame()裏面執行所有排程的函式的過程中,如果我們在for迴圈還未結束前隨意刪除函式陣列裏的函式,導至陣列裏函式的位置變來變去,那麼就會發生某些函式被跳過沒執行到,或某些函式被執行兩次的慘劇。

因此在移除函式時,先設null,然後由Updater來進行實際移除的工作才安全。

為什麼要用箭頭函式

在這系列的第一篇《Trick 1: 萬惡的摸彩箱》也有提到箭頭函式,不過當時大家所知不多,我只簡略提了一下關鍵字this用在一般函式和箭頭函式裏有不同的意義。

這裏我們用一個更容易體會的例子,來解釋給同學們聽。

/** 定義遊戲的類別 */
class Game {
    name = "一場遊戲一場夢";
    // 一般函式
    update1() {
        console.log(this.name);
    }
    // 箭頭函式
    update2 = () => {
        console.log(this.name);
    }
}
// 建立遊戲
let game = new Game();
// 新增一個更新循環系統
let updater = new Updater();
// 開始運作
updater.start(30);
// 排入遊戲的更新函式
updater.addFunction(game.update1);
updater.addFunction(game.update2);

在上面的例子中,game的兩個函式都會被updater呼叫,但是在執行update1()時會跳出錯誤,表示this裏面沒有"name"這個屬性,而在執行update2()時卻能正常列印出"一場遊戲一場夢"。

會有這樣的結果,是因為當我們把update2這個箭頭函式交給updater去執行的時候,updater2記得自己的主人是game,所以在使用this.name的時候,可以正確地在game裏面找到"name"這個屬性。

但是把update1交給updater去執行的時候,updater1不會記得他的主人是game,所以在使用this.name的時候,發現this不知道是什麼(undefined),進而發生錯誤。

以上這兩項說明,都是初學者非常容易搞出BUG卻找不到問題所在的地方,所以這邊特別在這次的範例中提出來,希望同學們能少走點冤枉路。


上一篇
Trick 12: 直男與硬漢的交點-兩條線段的碰撞問題
下一篇
Trick 14: 為什麼要寫自己的亂數產生器
系列文
30個遊戲程設的錦囊妙計32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言